package net.sf.javamapper.factory;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Properties;

import net.sf.javamapper.annotations.Delete;
import net.sf.javamapper.annotations.Insert;
import net.sf.javamapper.annotations.Result;
import net.sf.javamapper.annotations.ResultMap;
import net.sf.javamapper.annotations.Select;
import net.sf.javamapper.annotations.Update;

import org.springframework.core.NestedIOException;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PropertiesLoaderUtils;
import org.springframework.orm.ibatis.SqlMapClientFactoryBean;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import com.ibatis.common.xml.NodeletException;
import com.ibatis.sqlmap.client.SqlMapClient;
import com.ibatis.sqlmap.engine.builder.xml.SqlMapConfigParser;
import com.ibatis.sqlmap.engine.builder.xml.SqlMapParser;
import com.ibatis.sqlmap.engine.builder.xml.XmlParserState;
import com.ibatis.sqlmap.engine.config.MappedStatementConfig;
import com.ibatis.sqlmap.engine.config.ResultMapConfig;
import com.ibatis.sqlmap.engine.config.SqlSource;
import com.ibatis.sqlmap.engine.mapping.sql.Sql;
import com.ibatis.sqlmap.engine.mapping.sql.raw.RawSql;
import com.ibatis.sqlmap.engine.mapping.statement.DeleteStatement;
import com.ibatis.sqlmap.engine.mapping.statement.InsertStatement;
import com.ibatis.sqlmap.engine.mapping.statement.MappedStatement;
import com.ibatis.sqlmap.engine.mapping.statement.SelectStatement;
import com.ibatis.sqlmap.engine.mapping.statement.UpdateStatement;

public class JavaSqlMapClientFactoryBean extends SqlMapClientFactoryBean {

	private Collection<Class<?>> mapperClasses;

	public void setMapperClasses(Collection<Class<?>> mapperClasses) {
		this.mapperClasses = mapperClasses;
	}

	protected SqlMapClient buildSqlMapClient(Resource[] configLocations, Resource[] mappingLocations, Properties properties)
			throws IOException {

		if (ObjectUtils.isEmpty(configLocations)) { //
			throw new IllegalArgumentException("At least 1 'configLocation' entry is required");
		}

		SqlMapConfigParser configParser = new SqlMapConfigParser();
		SqlMapClient client = null;
		XmlParserState state = SqlMapParserFactory.createXmlParserState(configParser);

		if (mapperClasses != null) {

			for (Class<?> mapperClass : mapperClasses) {

				if (mapperClass.isAnnotationPresent(ResultMap.class)) {

					String id = getSimpleName(mapperClass);
					String extended = null;
					String xmlName = null;
					Collection<String> groupByCollection = new ArrayList<String>();

					if (mapperClass.getSuperclass().isAnnotationPresent(ResultMap.class)) {
						extended = getSimpleName(mapperClass.getSuperclass());
					}

					ResultMap resultMap = mapperClass.getAnnotation(ResultMap.class);
					Class<?> resultClass = resultMap.resultClass().equals(Class.class) ? mapperClass : resultMap.resultClass();

					Collection<Method> resultGetters = new ArrayList<Method>();

					// Iterate over all the getters in search of properties to
					// be used with "groupBy"
					for (Method method : mapperClass.getDeclaredMethods()) {
						if (isGetter(method)) {
							if (method.isAnnotationPresent(Result.class)) {
								resultGetters.add(method);
								Result result = method.getAnnotation(Result.class);
								if (result.groupBy()) {
									groupByCollection.add(getPropertyName(method));
								}
							}
						}
					}
					String groupBy = StringUtils.collectionToCommaDelimitedString(groupByCollection);

					ResultMapConfig resultConfig = state.getConfig().newResultMapConfig(id, resultClass, groupBy, extended,
							xmlName);
					state.setResultConfig(resultConfig);

					// Iterate over all getters marked as properties and create
					// result mappings according to their annotations metadata
					for (Method resultGetter : resultGetters) {

						Result result = resultGetter.getAnnotation(Result.class);

						String propertyName = getPropertyName(resultGetter);
						String columnName = null;
						Integer columnIndex = null;
						Class<?> javaClass = null;
						String jdbcType = null;
						String nullValue = null;
						String notNullColumn = null;
						String statementName = null;
						String resultMapName = null;
						Class<?> typeHandlerImpl = null;

						columnName = getIfNotEmpty(result.column());
						jdbcType = getIfNotEmpty(result.jdbcType());
						nullValue = getIfNotEmpty(result.nullValue());
						if (isNotEmpty(result.resultMap())) {
							resultMapName = getSimpleName(result.resultMap());
						}
						if (isNotEmpty(result.typeHandler())) {
							typeHandlerImpl = result.typeHandler();
						}

						state.getResultConfig().addResultMapping(propertyName, columnName, columnIndex, javaClass, jdbcType,
								nullValue, notNullColumn, statementName, resultMapName, typeHandlerImpl);
					}
				}

				// Load the properties file that contains the statement
				// mappings for this class. By convention: file path is the
				// same as the package and the file name is the same as the
				// class name with ".properties" suffix.
				Properties statementMappings = null;
				String path = ClassUtils.classPackageAsResourcePath(mapperClass);
				String filename = mapperClass.getSimpleName() + ".properties";
				statementMappings = PropertiesLoaderUtils.loadAllProperties(path + '/' + filename);

				// Iterate over all the methods in search of mapped statements
				for (Method method : mapperClass.getDeclaredMethods()) {

					if (method.isAnnotationPresent(Select.class) || method.isAnnotationPresent(Insert.class)
							|| method.isAnnotationPresent(Update.class) || method.isAnnotationPresent(Delete.class)) {

						String id = method.getName();
						String parameterMapName = null; // TODO: parameter maps
						Class<?> parameterClass = null;
						String resultMapName = null;
						String[] additionalResultMapNames = null;
						Class<?> resultClass = null;
						Class<?>[] additionalResultClasses = null;
						String cacheModelName = null; // TODO: cache
						String resultSetType = null; // TODO: resultSetType
						Integer fetchSizeInt = null;
						boolean allowRemappingBool = false;
						Integer timeout = null;
						String xmlResultName = null;
						final MappedStatement statement;
						final String sql;

						if (method.isAnnotationPresent(Select.class)) {

							statement = new SelectStatement();

							Select select = method.getAnnotation(Select.class);

							if (isNotEmpty(select.value())) {
								sql = select.value();
							} else {
								sql = statementMappings.getProperty(id);
							}

							if (isNotEmpty(select.resultClassMapper())) {
								resultMapName = getSimpleName(select.resultClassMapper());
							} else {
								resultClass = method.getReturnType();
							}

							if (select.timeout() > 0) {
								timeout = select.timeout();
							}

						} else if (method.isAnnotationPresent(Insert.class)) {

							statement = new InsertStatement();

							Insert insert = method.getAnnotation(Insert.class);

							if (isNotEmpty(insert.value())) {
								sql = insert.value();
							} else {
								sql = statementMappings.getProperty(id);
							}

							if (insert.timeout() > 0) {
								timeout = insert.timeout();
							}

						} else if (method.isAnnotationPresent(Update.class)) {

							statement = new UpdateStatement();

							Update update = method.getAnnotation(Update.class);

							if (isNotEmpty(update.value())) {
								sql = update.value();
							} else {
								sql = statementMappings.getProperty(id);
							}

							if (update.timeout() > 0) {
								timeout = update.timeout();
							}

						} else if (method.isAnnotationPresent(Delete.class)) {

							statement = new DeleteStatement();

							Delete delete = method.getAnnotation(Delete.class);

							if (isNotEmpty(delete.value())) {
								sql = delete.value();
							} else {
								sql = statementMappings.getProperty(id);
							}

							if (delete.timeout() > 0) {
								timeout = delete.timeout();
							}

						} else {
							throw new IllegalStateException();
						}

						final SqlSource processor = new SqlSource() {
							public Sql getSql() {
								return new RawSql(sql);
							}
						};

						if (method.getParameterTypes().length > 0) {
							parameterClass = method.getParameterTypes()[0];
						}

						MappedStatementConfig statementConfig = state.getConfig().newMappedStatementConfig(id, statement,
								processor, parameterMapName, parameterClass, resultMapName, additionalResultMapNames,
								resultClass, additionalResultClasses, resultSetType, fetchSizeInt, allowRemappingBool, timeout,
								cacheModelName, xmlResultName);
					}
				}
			}
		}

		for (int i = 0; i < configLocations.length; i++) {
			InputStream is = configLocations[i].getInputStream();
			try {
				client = configParser.parse(is, properties);
			} catch (RuntimeException ex) {
				throw new NestedIOException("Failed to parse config resource: " + configLocations[i], ex.getCause());
			}
		}

		if (mappingLocations != null) {
			SqlMapParser mapParser = new SqlMapParser(state);
			for (int i = 0; i < mappingLocations.length; i++) {
				try {
					mapParser.parse(mappingLocations[i].getInputStream());
				} catch (NodeletException ex) {
					throw new NestedIOException("Failed to parse mapping resource: " + mappingLocations[i], ex);
				}
			}
		}

		return client;
	}

	private static String getSimpleName(Class<?> clazz) {
		return clazz.getSimpleName();
	}

	private static boolean isGetter(Method method) {
		return method.getName().length() > 3 && method.getName().startsWith("get");
	}

	private static String getPropertyName(Method getterMethod) {
		return StringUtils.uncapitalize(getterMethod.getName().substring(3));
	}

	private static boolean isNotEmpty(String string) {
		return string != null && string.trim().length() > 0;
	}

	private String getIfNotEmpty(String string) {
		return isNotEmpty(string) ? string : null;
	}

	private boolean isNotEmpty(Class<?> clazz) {
		return clazz != null && !clazz.equals(Class.class);
	}

	/**
	 * Inner class to avoid hard-coded iBATIS 2.3.2 dependency (XmlParserState class).
	 */
	private static class SqlMapParserFactory {

		public static XmlParserState createXmlParserState(SqlMapConfigParser configParser) {
			// Ideally: XmlParserState state = configParser.getState();
			// Should raise an enhancement request with iBATIS...
			XmlParserState state = null;
			try {
				Field stateField = SqlMapConfigParser.class.getDeclaredField("state");
				stateField.setAccessible(true);
				state = (XmlParserState) stateField.get(configParser);
			} catch (Exception ex) {
				throw new IllegalStateException("iBATIS 2.3.2 'state' field not found in SqlMapConfigParser class - "
						+ "please upgrade to IBATIS 2.3.2 or higher in order to use the new 'mappingLocations' feature. " + ex);
			}
			return state;
		}
	}
}
